/*
 * Copyright (c) 2009, Jan Pastor. All Rights Reserved.
 */

package sk.jp.imageio.jpeg;

import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FilenameFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.FileImageOutputStream;

import org.apache.sanselan.ImageReadException;
import org.apache.sanselan.ImageWriteException;
import org.apache.sanselan.Sanselan;
import org.apache.sanselan.common.IImageMetadata;
import org.apache.sanselan.formats.jpeg.JpegImageMetadata;
import org.apache.sanselan.formats.jpeg.JpegPhotoshopMetadata;
import org.apache.sanselan.formats.jpeg.exifRewrite.ExifRewriter;
import org.apache.sanselan.formats.jpeg.iptc.IPTCConstants;
import org.apache.sanselan.formats.jpeg.iptc.IPTCRecord;
import org.apache.sanselan.formats.jpeg.iptc.IPTCType;
import org.apache.sanselan.formats.jpeg.iptc.JpegIptcRewriter;
import org.apache.sanselan.formats.jpeg.iptc.PhotoshopApp13Data;
import org.apache.sanselan.formats.tiff.TiffContents;
import org.apache.sanselan.formats.tiff.TiffField;
import org.apache.sanselan.formats.tiff.TiffImageMetadata;
import org.apache.sanselan.formats.tiff.constants.TiffConstants;
import org.apache.sanselan.formats.tiff.write.TiffOutputSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import sk.jp.commons.exception.ApplicationException;
import sk.jp.commons.utils.ExceptionUtils;
import sk.jp.commons.utils.LogMsg;
import sk.jp.commons.utils.LogUtil;
import sk.jp.commons.utils.FileUtil;
import sk.jp.imageio.jpeg.metadata.Info;
import sk.jp.imageio.jpeg.metadata.Tag;

/**
 * <p>Thread safe.
 */
public class JpegImageIoImpl implements JpegImageIo {

    private static final Logger log = LoggerFactory.getLogger(JpegImageIoImpl.class);

    private static final IPTCType IPTC_RECORD_TYPE_TAG = IPTCConstants.IPTC_TYPE_KEYWORDS;
    private static final IPTCType IPTC_RECORD_TYPE_RATING = IPTCConstants.IPTC_TYPE_SPECIAL_INSTRUCTIONS;
    private static final String FORMAT_NAME = "jpg";
    private static final String FORMAT_EXTENSION = "jpg";

    private float compressionQuality = 0.98f;
    private boolean lazyEnabled = false;

    //----------------------------------------------------------------------

    /**
     * Set compression quality property, used for saving image data to file.
     * @param compressionQuality compression quality in range 1 to 100, where
     * 1 is the least and 100 is higher quality.
     */
    public void setCompressionQuality(int compressionQuality) {
        if(compressionQuality < 1){
            this.compressionQuality = 0.01f;
        }else if(compressionQuality > 100){
            this.compressionQuality = 1.0f;
        }else{
            this.compressionQuality = compressionQuality / 100.0f;
        }
    }

    /**
     * Enable lazy loading support.
     * @param lazyEnabled
     */
    public void setLazyEnabled(boolean lazyEnabled) {
        this.lazyEnabled = lazyEnabled;
    }

    public JpegImage loadImage(File file) {
        log.debug(LogMsg.inp("file", file));
        if(log.isTraceEnabled()){
            log.trace(LogUtil.getMemoryStateLogMessage());
        }

        try {
            JpegImageMetadata jmd = readJpegImageMetadata(file);
            String dateCreated = getCreateDate(jmd);
            Info info = new Info(file.getAbsolutePath(), dateCreated,
                                 new Date(file.lastModified()));
            List records = readIptcRecords(jmd);

            Set<Tag> tags = new LinkedHashSet<Tag>();
            int rating = 0;
            for (Object record : records) {
                IPTCRecord iptcRecord = (IPTCRecord) record;
                String value = iptcRecord.getValue();
                log.trace(LogMsg.toString("IPTC record",
                                          "type", iptcRecord.getIptcTypeName(),
                                          "value", value));
                if(iptcRecord.iptcType.type == IPTC_RECORD_TYPE_TAG.type){
                    tags.add(new Tag(value));
                }else if(iptcRecord.iptcType.type == IPTC_RECORD_TYPE_RATING.type){
                    try {
                        rating = Integer.parseInt(value);
                    } catch (NumberFormatException e) {
                        //ignored
                    }
                }
            }
            if(log.isTraceEnabled()){
                log.trace(LogMsg.toString("Image metadata read",
                                          "dateCreated", dateCreated,
                                          "tags", tags, "rating", rating));
            }

            JpegImage image;
            if(lazyEnabled){
                image = new JpegImageLazyImpl(info);
            }else{
                BufferedImage bufferedImage = ImageUtil.loadPictureData(file);
                if(log.isTraceEnabled()){
                    log.trace(LogMsg.toString("Image picture data read"));
                }            
                image = new JpegImageImpl(info, bufferedImage);
            }

            image.getTags().addAll(tags);
            image.getRating().setValue(rating);

            if(log.isTraceEnabled()){
                log.trace(LogUtil.getMemoryStateLogMessage());
            }
            if(log.isTraceEnabled()){
                log.trace(LogMsg.out("image", image));
            }
            return image;
        } catch (ApplicationException ae) {
            throw ae;
        } catch (Throwable e) {
            throw new ApplicationException("Can't load image", "sk.jp.jpeg-imageio.io.err.load",
                                           ExceptionUtils.paramsToMap("file", file), e);
        }
    }

    public List<JpegImage> loadImages(Set<File> directories) {
        if(log.isDebugEnabled()){
            log.debug(LogMsg.inp("directories", directories));
        }
        if(log.isTraceEnabled()){
            log.trace(LogUtil.getMemoryStateLogMessage());
        }

        List<JpegImage> images = new ArrayList<JpegImage>();
        for (File directory : directories) {
            if(!directory.isDirectory()){
                continue;
            }
            if(log.isDebugEnabled()){
                log.debug(LogMsg.toString("Reading directory",
                                          "directory", directory.getAbsolutePath()));
            }
            File[] files = directory.listFiles(new FilenameFilter() {
                public boolean accept(File dir, String name) {
                    return name.toLowerCase().endsWith("."+FORMAT_EXTENSION);
                }
            });
            for (File file : files) {
                images.add(loadImage(file));
            }
        }

        if(log.isTraceEnabled()){
            log.trace(LogUtil.getMemoryStateLogMessage());
        }
        if(log.isTraceEnabled()){
            log.trace(LogMsg.out("images", images));
        }
        return images;
    }

    public JpegImage saveImage(JpegImage image) {
        if(log.isTraceEnabled()){
            log.trace(LogMsg.inp("image", image));
        }else if(log.isDebugEnabled()){
            log.debug(LogMsg.inp("image", image.getInfo().getFilePath()));
        }
        if(log.isTraceEnabled()){
            log.trace(LogUtil.getMemoryStateLogMessage());
        }

        try {
            //Get metadata from original file
            File imageFile = new File(image.getInfo().getFilePath());
            JpegImageMetadata jmd = readJpegImageMetadata(imageFile);
            TiffOutputSet exifMetadata = getExifMetadata(jmd);
            JpegImageMetadata iptcMetadata
                = createEmptyMetadataWithIptc(jmd.getPhotoshop().photoshopApp13Data);
            //Save picture data without metadata
            File pictureTempFile = FileUtil.getTempFile();
            savePictureData(pictureTempFile, image.getBufferedImage());
            //Save EXIF metadata to file with picure (if exists)
            File pitureAndExifTempFile = FileUtil.getTempFile();
            if(exifMetadata != null){
                FileOutputStream os = new FileOutputStream(pitureAndExifTempFile);
                ExifRewriter exifRewriter = new ExifRewriter();
                exifRewriter.updateExifMetadataLossless(pictureTempFile, os, exifMetadata);
                os.flush();
                os.close();
            }else{
                FileUtil.moveFile(pictureTempFile, pitureAndExifTempFile);
            }
            //Delete picture temp file (already unnecessary)
            try {
                if(pictureTempFile.exists() && !pictureTempFile.delete()){
                    throw new IOException("Can't delete file");
                }
            }catch(Exception e){
                log.warn(LogMsg.toString("Can't delete unnecessary temp file",
                           "pictureTempFile", pictureTempFile.getAbsolutePath()), e);
            }
            //Create backup copy of original image file
            File imageBackupFile = FileUtil.getTempFile();
            try {
                FileUtil.copyFile(imageFile, imageBackupFile);
            } catch (Exception e) {
                throw new ApplicationException("Can't create backup copy of image file",
                                               "sk.jp.jpeg-imageio.io.err.save.backup",
                               ExceptionUtils.paramsToMap(
                                   "imageBackupFile", imageBackupFile.getAbsolutePath()),
                               e);
            }
            //Delete original file (will be replaced with new save)
            try {
                if(!imageFile.delete()){
                    throw new IOException("Unsuccessful delete");
                }
            }catch(Exception e){
                throw new ApplicationException("Can't delete original file",
                                               "sk.jp.jpeg-imageio.io.err.delete.orig",
                                    ExceptionUtils.paramsToMap("imageFile",
                                                    imageFile.getAbsolutePath()), e);
            }
            //Save IPTC metadata to file with picture and EXIF (rewrites original file, so create backup)
            try {
                saveImageMetadata(image, pitureAndExifTempFile, imageFile, iptcMetadata);
                //Delete picture+EXIF temp file (already unnecessary)
                try {
                    if(!pitureAndExifTempFile.delete()){
                        throw new IOException("Can't delete file");
                    }
                }catch(Exception e){
                    log.warn(LogMsg.toString("Can't delete unnecessary temp file",
                                        "pitureAndExifTempFile",
                                        pitureAndExifTempFile.getAbsolutePath()), e);
                }
                //Delete backup copy after successful save of image
                try {
                    if(!imageBackupFile.delete()){
                        throw new IOException("Can't delete file");
                    }
                }catch(Exception e){
                    log.warn(LogMsg.toString("Can't delete unnecessary backup file",
                                        "imageBackupFile",
                                        imageBackupFile.getAbsolutePath()), e);
                }
            } catch (Exception e) {
                //Try to move backup to original file
                try {
                    FileUtil.moveFile(imageBackupFile, imageFile);
                    log.info(LogMsg.toString("Saving file failed but original was successfully restored from backup",
                                             "imageFile", imageFile.getAbsolutePath()));
                } catch (Exception e2){
                    log.warn(LogMsg.toString("Can't unbackup image file after unsuccessfull save",
                                             "backupFile", imageBackupFile.getAbsolutePath(),
                                             "imageFile", imageFile.getAbsolutePath()), 
                             e2);
                }
                throw e;
            }
            //
            image.getInfo().setFileLastModifDate(new Date(imageFile.lastModified()));
            if(log.isTraceEnabled()){
                log.trace(LogUtil.getMemoryStateLogMessage());
            }
            log.trace(LogMsg.toString("Image saved to file",
                                      "file", image.getInfo().getFilePath()));
            return image;
        } catch (ApplicationException ae) {
            throw ae;
        } catch (Throwable e) {
            throw new ApplicationException("Can't save image data",
                                           "sk.jp.jpeg-imageio.io.err.save.image",
                                           ExceptionUtils.paramsToMap("image", image),
                                           e);
        }
    }

    public JpegImage saveImageMetadata(JpegImage image) {
        if(log.isTraceEnabled()){
            log.trace(LogMsg.inp("image", image));
        }else if(log.isDebugEnabled()){
            log.debug(LogMsg.inp("image", image.getInfo().getFilePath()));
        }
        if(log.isTraceEnabled()){
            log.trace(LogUtil.getMemoryStateLogMessage());
        }

        try {
            File imageFile = new File(image.getInfo().getFilePath());
            //Create backup copy of original image file
            File imageBackupFile = FileUtil.getTempFile();
            try {
                FileUtil.copyFile(imageFile, imageBackupFile);
            } catch (Exception e) {
                throw new ApplicationException("Can't create backup copy of image file",
                                               "sk.jp.jpeg-imageio.io.err.save.backup",
                               ExceptionUtils.paramsToMap(
                                   "imageBackupFile", imageBackupFile.getAbsolutePath()),
                               e);
            }
            //Delete original file (will be replaced with new save)
            try {
                if(!imageFile.delete()){
                    throw new IOException("Unsuccessful delete");
                }
            }catch(Exception e){
                throw new ApplicationException("Can't delete original file",
                                               "sk.jp.jpeg-imageio.io.err.delete.orig",
                                    ExceptionUtils.paramsToMap("imageFile",
                                                    imageFile.getAbsolutePath()), e);
            }
            //Save IPTC metadata to file with picture and EXIF (rewrites original file, so create backup)
            try {
                saveImageMetadata(image, imageBackupFile, imageFile);
                //Delete backup copy after successful save of image
                try {
                    if(!imageBackupFile.delete()){
                        throw new IOException("Can't delete file");
                    }
                }catch(Exception e){
                    log.warn(LogMsg.toString("Can't delete unnecessary backup file",
                                        "imageBackupFile",
                                        imageBackupFile.getAbsolutePath()), e);
                }
            } catch (Exception e) {
                //Try to move backup to original file
                try {
                    FileUtil.moveFile(imageBackupFile, imageFile);
                    log.info(LogMsg.toString("Saving file failed but original was successfully restored from backup",
                                             "imageFile", imageFile.getAbsolutePath()));
                } catch (Exception e2){
                    log.warn(LogMsg.toString("Can't unbackup image file after unsuccessfull save",
                                             "backupFile", imageBackupFile.getAbsolutePath(),
                                             "imageFile", imageFile.getAbsolutePath()),
                             e2);
                }
                throw e;
            }
            //
            image.getInfo().setFileLastModifDate(new Date(imageFile.lastModified()));

            if(log.isTraceEnabled()){
                log.trace(LogUtil.getMemoryStateLogMessage());
            }
            log.trace(LogMsg.toString("Image saved to file",
                                      "imageFile", imageFile.getAbsolutePath()));
            return image;
        } catch (ApplicationException ae) {
            throw ae;
        } catch (Throwable e) {
            throw new ApplicationException("Can't save image metadata",
                                           "sk.jp.jpeg-imageio.io.err.save.meta",
                                           ExceptionUtils.paramsToMap("image", image), e);
        }
    }

    //----------------------------------------------------------------------

    /**
     * Reads JPEG image metadata from <code>file</code>.
     * @param file image file.
     * @return metadata.
     * @throws ImageReadException
     * @throws IOException
     */
    private JpegImageMetadata readJpegImageMetadata(File file) throws ImageReadException, IOException {
        IImageMetadata md = Sanselan.getMetadata(file);
        if(md == null){
            log.debug("Missing image metadata, creating new empty");
            md = createEmptyMetadata();
        }
        if(!(md instanceof JpegImageMetadata)){
            throw new ApplicationException("Unsupported metadata class",
                                           "sk.jp.jpeg-imageio.io.err.meta.uncls",
                           ExceptionUtils.paramsToMap("class", md.getClass().getName()));
        }

        JpegImageMetadata jmd = (JpegImageMetadata) md;
        JpegPhotoshopMetadata pmd = jmd.getPhotoshop();
        TiffImageMetadata exif = jmd.getExif();
        JpegImageMetadata tmpJmd = createEmptyMetadata();

        if(pmd == null){
            pmd = tmpJmd.getPhotoshop();
        }
        if(exif == null){
            exif = tmpJmd.getExif();
        }

        jmd = new JpegImageMetadata(pmd, exif);
        return jmd;
    }

    /**
     * Creates empty image metadata.
     * @return empty image metadata.
     */
    private JpegImageMetadata createEmptyMetadata() {
        PhotoshopApp13Data app13Data = new PhotoshopApp13Data(new ArrayList(0),
                                                              new ArrayList(0));
        return createEmptyMetadataWithIptc(app13Data);
    }

    /**
     * Creates empty image metadata with given IPTC data.
     * @return empty image metadata.
     */
    private JpegImageMetadata createEmptyMetadataWithIptc(PhotoshopApp13Data app13Data) {
        JpegImageMetadata md;
        TiffContents tiffContents = new TiffContents(null, new ArrayList(0));
        TiffImageMetadata tiffImageMetadata = new TiffImageMetadata(tiffContents);
        md = new JpegImageMetadata(new JpegPhotoshopMetadata(app13Data),
                                   tiffImageMetadata);
        return md;
    }

    /**
     * Reads EXIF <i>Create Data</i> from given metadata.
     * @param jmd image metadata.
     * @return create date as string or empty string if not found.
     * @throws ImageReadException
     */
    private String getCreateDate(JpegImageMetadata jmd) throws ImageReadException {
        TiffField createDate = jmd.getExif().findField(TiffConstants.EXIF_TAG_CREATE_DATE);
        String dateCreated = "";
        if(createDate != null && createDate.getValue() != null){
            String value = createDate.getValue().toString();
            log.trace(LogMsg.toString("Removing last zero character",
                                      "createDateValue", value, "length", value.length()));
            int inx = value.lastIndexOf(0);
            dateCreated = inx != -1 ? value.substring(0, inx) : value;
        }
        return dateCreated;
    }

    /**
     * Read IPTC records from given metadata.
     * @param jmd JPEG image metadata.
     * @return IPTC records.
     */
    @SuppressWarnings({"unchecked"})
    private List<IPTCRecord> readIptcRecords(JpegImageMetadata jmd) {
        JpegPhotoshopMetadata pmd = jmd.getPhotoshop();
        if(pmd == null){
            PhotoshopApp13Data app13Data = new PhotoshopApp13Data(new ArrayList(),
                                                                  new ArrayList());
            pmd = new JpegPhotoshopMetadata(app13Data);
        }
        PhotoshopApp13Data photoshopApp13Data = pmd.photoshopApp13Data;
        List records = photoshopApp13Data.getRecords();
        return (List<IPTCRecord>) records;
    }

    /**
     * Prepares a list of IPTC records to save.
     * <p>Returns a list of records that are not a type {@link #IPTC_RECORD_TYPE_TAG}
     * or {@link #IPTC_RECORD_TYPE_RATING}.
     * @param records
     * @param recordsToSave
     */
    private void prepareIptcRecordsToSave(List records, List<IPTCRecord> recordsToSave) {
        for (Object record : records) {
            IPTCRecord iptcRecord = (IPTCRecord) record;
            if(iptcRecord.iptcType.type != IPTC_RECORD_TYPE_TAG.type
                && iptcRecord.iptcType.type != IPTC_RECORD_TYPE_RATING.type){
                recordsToSave.add(iptcRecord);
            }
        }
    }

    /**
     * Saves image metadata (provided by <code>image</code> instance) to
     * <code>destFile </code>, additionaly with picture data and metadata from
     * <code>origFile </code>.
     * @param image image instance to save.
     * @param origFile file with original picture data and metadata.
     * @param destFile dest file (must by different to <code>origFile </code>.
     * @throws ImageReadException
     * @throws IOException
     * @throws ImageWriteException
     */
    private void saveImageMetadata(JpegImage image, File origFile, File destFile)
                        throws ImageReadException, IOException, ImageWriteException {
        JpegImageMetadata jmd = readJpegImageMetadata(origFile);
        saveImageMetadata(image, origFile, destFile, jmd);
    }

    /**
     * Saves image metadata (provided by <code>image</code> instance) and given
     * in <code>jmd</code> to
     * <code>destFile </code>, additionaly with picture data and metadata from
     * <code>origFile</code>.
     * @param image image instance to save.
     * @param origFile file with original picture data and metadata.
     * @param destFile dest file (must by different to <code>origFile </code>.
     * @param jmd image metadata , where only
     * <code>jmd.getPhotoshop().photoshopApp13Data</code> are used to save.
     * @throws ImageReadException
     * @throws IOException
     * @throws ImageWriteException
     */
    private void saveImageMetadata(JpegImage image, File origFile, File destFile,
                                   JpegImageMetadata jmd)
                            throws ImageReadException, IOException, ImageWriteException {
        List records = readIptcRecords(jmd);
        List<IPTCRecord> recordsToSave = new ArrayList<IPTCRecord>(records.size());
        prepareIptcRecordsToSave(records, recordsToSave);

        for (Tag tag : image.getTags()) {
            recordsToSave.add(new IPTCRecord(IPTC_RECORD_TYPE_TAG, tag.getName()));
        }
        recordsToSave.add(new IPTCRecord(IPTC_RECORD_TYPE_RATING,
                                         ""+image.getRating().getValue()));

        JpegIptcRewriter jpegIptcRewriter = new JpegIptcRewriter();

        FileOutputStream os = null;
        try {
            os = new FileOutputStream(destFile);
            List rawBlocks = jmd.getPhotoshop().photoshopApp13Data.getRawBlocks();
            jpegIptcRewriter.writeIPTC(origFile, os,
                                       new PhotoshopApp13Data(recordsToSave, rawBlocks));
        } finally {
            if(os != null){
                os.flush();
                os.close();
            }
        }
    }

    /**
     * Returns image writer (for picture data) for specified <code>formatName</code>.
     * @param formatName format name.
     * @return image writer.
     */
    private ImageWriter getImageWriter(String formatName) {
        Iterator<ImageWriter> imageWriterIterator = ImageIO.getImageWritersByFormatName(formatName);
        ImageWriter imageWriter;
        if (imageWriterIterator.hasNext()) {
            imageWriter = imageWriterIterator.next();
        } else {
            throw new ApplicationException("Unsupported image writer for format",
                                           "sk.jp.jpeg-imageio.io.err.image.wr.uns",
                                           ExceptionUtils.paramsToMap("format", formatName));
        }
        return imageWriter;
    }

    /**
     * Saves picture data to file, without image metadata.
     * @param file
     * @param bufferedImage
     * @throws IOException
     */
    private void savePictureData(File file, BufferedImage bufferedImage) throws IOException {
        log.trace(LogMsg.inp("file", file.getAbsolutePath()));

        ImageWriter imageWriter = getImageWriter(FORMAT_NAME);
        ImageWriteParam writeParam = imageWriter.getDefaultWriteParam();
        writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
        writeParam.setCompressionQuality(compressionQuality);
        try {
            if(file.exists() && !file.delete()){
                throw new IOException("Unsuccessful delete");
            }
        }catch(Exception e){
            log.warn("Can't delete original file", e);
        }
        FileImageOutputStream os = new FileImageOutputStream(file);
        imageWriter.setOutput(os);
        imageWriter.write(null, new IIOImage(bufferedImage, null, null),
                          writeParam);
        os.flush();
        os.close();
    }

    /**
     * Returns EXIF metadata from given metadata structure.
     * @param jmd
     * @return EXIF metadata or null set if no EXIF metadata was found.
     * @throws ImageWriteException
     */
    private TiffOutputSet getExifMetadata(JpegImageMetadata jmd) throws ImageWriteException {
        TiffImageMetadata exif = jmd.getExif();
        if(exif.contents != null && exif.contents.header != null){
            return exif.getOutputSet();
        }else{
            return null;
        }
    }

}
